Revert "webapp: Remove support for local pairing"
authorJoey Hess <joeyh@joeyh.name>
Mon, 29 Sep 2025 19:53:38 +0000 (15:53 -0400)
committerJoey Hess <joeyh@joeyh.name>
Mon, 29 Sep 2025 19:53:38 +0000 (15:53 -0400)
This reverts commit 8ea6d7acc548cb35b4905c9c663e8a7de66ac752.

Temporarily, until builds finish for today's release.

20 files changed:
Assistant.hs
Assistant/Pairing/MakeRemote.hs [new file with mode: 0644]
Assistant/Pairing/Network.hs [new file with mode: 0644]
Assistant/Threads/PairListener.hs [new file with mode: 0644]
Assistant/WebApp/Configurators/Pairing.hs
Assistant/WebApp/routes
BuildFlags.hs
CHANGELOG
debian/control
doc/assistant.mdwn
doc/assistant/local_pairing_walkthrough.mdwn [new file with mode: 0644]
doc/assistant/remote_sharing_walkthrough.mdwn
doc/bugs/Local_network___40__ssh__41___fails_to_pair__47__sync.mdwn
doc/todo/remove_webapp.mdwn
git-annex.cabal
stack.yaml
templates/configurators/addrepository/misc.hamlet
templates/configurators/pairing/disabled.hamlet [new file with mode: 0644]
templates/configurators/pairing/local/inprogress.hamlet [new file with mode: 0644]
templates/configurators/pairing/local/prompt.hamlet [new file with mode: 0644]

index cd818958611acc18d964e7e137e157c41f866637..911ebd33d35da21656d9664d1e56ad5b6fa8e477 100644 (file)
@@ -40,6 +40,9 @@ import Assistant.Threads.Glacier
 #ifdef WITH_WEBAPP
 import Assistant.WebApp
 import Assistant.Threads.WebApp
+#ifdef WITH_PAIRING
+import Assistant.Threads.PairListener
+#endif
 #else
 import Assistant.Types.UrlRenderer
 #endif
@@ -152,6 +155,11 @@ startDaemon assistant foreground startdelay cannotrun listenhost listenport star
                        then webappthread
                        else webappthread ++
                                [ watch commitThread
+#ifdef WITH_WEBAPP
+#ifdef WITH_PAIRING
+                               , assist $ pairListenerThread urlrenderer
+#endif
+#endif
                                , assist pushThread
                                , assist pushRetryThread
                                , assist exportThread
diff --git a/Assistant/Pairing/MakeRemote.hs b/Assistant/Pairing/MakeRemote.hs
new file mode 100644 (file)
index 0000000..f4468bc
--- /dev/null
@@ -0,0 +1,98 @@
+{- git-annex assistant pairing remote creation
+ -
+ - Copyright 2012 Joey Hess <id@joeyh.name>
+ -
+ - Licensed under the GNU AGPL version 3 or higher.
+ -}
+
+module Assistant.Pairing.MakeRemote where
+
+import Assistant.Common
+import Assistant.Ssh
+import Assistant.Pairing
+import Assistant.Pairing.Network
+import Assistant.MakeRemote
+import Assistant.Sync
+import Config.Cost
+import Config
+import qualified Types.Remote as Remote
+
+import Network.Socket
+import qualified Data.Text as T
+
+{- Authorized keys are set up before pairing is complete, so that the other
+ - side can immediately begin syncing. -}
+setupAuthorizedKeys :: PairMsg -> OsPath -> IO ()
+setupAuthorizedKeys msg repodir = case validateSshPubKey $ remoteSshPubKey $ pairMsgData msg of
+       Left err -> giveup err
+       Right pubkey -> do
+               absdir <- absPath repodir
+               unlessM (liftIO $ addAuthorizedKeys True absdir pubkey) $
+                       giveup "failed setting up ssh authorized keys"
+
+{- When local pairing is complete, this is used to set up the remote for
+ - the host we paired with. -}
+finishedLocalPairing :: PairMsg -> SshKeyPair -> Assistant ()
+finishedLocalPairing msg keypair = do
+       sshdata <- liftIO $ installSshKeyPair keypair =<< pairMsgToSshData msg
+       {- Ensure that we know the ssh host key for the host we paired with.
+        - If we don't, ssh over to get it. -}
+       liftIO $ unlessM (knownHost $ sshHostName sshdata) $
+               void $ sshTranscript
+                       [ sshOpt "StrictHostKeyChecking" "no"
+                       , sshOpt "NumberOfPasswordPrompts" "0"
+                       , "-n"
+                       ]
+                       (genSshHost (sshHostName sshdata) (sshUserName sshdata))
+                       ("git-annex-shell -c configlist " ++ T.unpack (sshDirectory sshdata))
+                       Nothing
+       r <- liftAnnex $ addRemote $ makeSshRemote sshdata
+       repo <- liftAnnex $ Remote.getRepo r
+       liftAnnex $ setRemoteCost repo semiExpensiveRemoteCost
+       syncRemote r
+
+{- Mostly a straightforward conversion.  Except:
+ -  * Determine the best hostname to use to contact the host.
+ -  * Strip leading ~/ from the directory name.
+ -}
+pairMsgToSshData :: PairMsg -> IO SshData
+pairMsgToSshData msg = do
+       let d = pairMsgData msg
+       hostname <- liftIO $ bestHostName msg
+       let dir = case remoteDirectory d of
+               ('~':'/':v) -> v
+               v -> v
+       return SshData
+               { sshHostName = T.pack hostname
+               , sshUserName = Just (T.pack $ remoteUserName d)
+               , sshDirectory = T.pack dir
+               , sshRepoName = genSshRepoName hostname (toOsPath dir)
+               , sshPort = 22
+               , needsPubKey = True
+               , sshCapabilities = [GitAnnexShellCapable, GitCapable, RsyncCapable]
+               , sshRepoUrl = Nothing
+               }
+
+{- Finds the best hostname to use for the host that sent the PairMsg.
+ -
+ - If remoteHostName is set, tries to use a .local address based on it.
+ - That's the most robust, if this system supports .local.
+ - Otherwise, looks up the hostname in the DNS for the remoteAddress,
+ - if any. May fall back to remoteAddress if there's no DNS. Ugh. -}
+bestHostName :: PairMsg -> IO HostName
+bestHostName msg = case remoteHostName $ pairMsgData msg of
+       Just h -> do
+               let localname = h ++ ".local"
+               addrs <- catchDefaultIO [] $
+                       getAddrInfo Nothing (Just localname) Nothing
+               maybe fallback (const $ return localname) (headMaybe addrs)
+       Nothing -> fallback
+  where
+       fallback = do
+               let a = pairMsgAddr msg
+               let sockaddr = case a of
+                       IPv4Addr addr -> SockAddrInet (fromInteger 0) addr
+                       IPv6Addr addr -> SockAddrInet6 (fromInteger 0) 0 addr 0
+               fromMaybe (showAddr a)
+                       <$> catchDefaultIO Nothing
+                               (fst <$> getNameInfo [] True False sockaddr)
diff --git a/Assistant/Pairing/Network.hs b/Assistant/Pairing/Network.hs
new file mode 100644 (file)
index 0000000..62a4ea0
--- /dev/null
@@ -0,0 +1,132 @@
+{- git-annex assistant pairing network code
+ -
+ - All network traffic is sent over multicast UDP. For reliability,
+ - each message is repeated until acknowledged. This is done using a
+ - thread, that gets stopped before the next message is sent.
+ -
+ - Copyright 2012 Joey Hess <id@joeyh.name>
+ -
+ - Licensed under the GNU AGPL version 3 or higher.
+ -}
+
+module Assistant.Pairing.Network where
+
+import Assistant.Common
+import Assistant.Pairing
+import Assistant.DaemonStatus
+import Utility.ThreadScheduler
+import Utility.Verifiable
+
+import Network.Multicast
+import Network.Info
+import Network.Socket
+import qualified Network.Socket.ByteString as B
+import qualified Data.ByteString.UTF8 as BU8
+import qualified Data.Map as M
+import Control.Concurrent
+
+{- This is an arbitrary port in the dynamic port range, that could
+ - conceivably be used for some other broadcast messages.
+ - If so, hope they ignore the garbage from us; we'll certainly
+ - ignore garbage from them. Wild wild west. -}
+pairingPort :: PortNumber
+pairingPort = 55556
+
+{- Goal: Reach all hosts on the same network segment.
+ - Method: Use same address that avahi uses. Other broadcast addresses seem
+ - to not be let through some routers. -}
+multicastAddress :: AddrClass -> HostName
+multicastAddress IPv4AddrClass = "224.0.0.251"
+multicastAddress IPv6AddrClass = "ff02::fb"
+
+{- Multicasts a message repeatedly on all interfaces, with a 2 second
+ - delay between each transmission. The message is repeated forever
+ - unless a number of repeats is specified.
+ -
+ - The remoteHostAddress is set to the interface's IP address.
+ -
+ - Note that new sockets are opened each time. This is hardly efficient,
+ - but it allows new network interfaces to be used as they come up.
+ - On the other hand, the expensive DNS lookups are cached.
+ -}
+multicastPairMsg :: Maybe Int -> Secret -> PairData -> PairStage -> IO ()
+multicastPairMsg repeats secret pairdata stage = go M.empty repeats
+  where
+       go _ (Just 0) = noop
+       go cache n = do
+               addrs <- activeNetworkAddresses
+               let cache' = updatecache cache addrs
+               mapM_ (sendinterface cache') addrs
+               threadDelaySeconds (Seconds 2)
+               go cache' $ pred <$> n
+       {- The multicast library currently chokes on ipv6 addresses. -}
+       sendinterface _ (IPv6Addr _) = noop
+       sendinterface cache i = void $ tryIO $
+               withSocketsDo $ bracket setup cleanup use
+         where
+               setup = multicastSender (multicastAddress IPv4AddrClass) pairingPort
+               cleanup (sock, _) = close sock -- FIXME does not work
+               use (sock, addr) = do
+                       setInterface sock (showAddr i)
+                       maybe noop
+                               (\s -> void $ B.sendTo sock (BU8.fromString s) addr)
+                               (M.lookup i cache)
+       updatecache cache [] = cache
+       updatecache cache (i:is)
+               | M.member i cache = updatecache cache is
+               | otherwise = updatecache (M.insert i (show $ mkmsg i) cache) is
+       mkmsg addr = PairMsg $
+               mkVerifiable (stage, pairdata, addr) secret
+
+startSending :: PairingInProgress -> PairStage -> (PairStage -> IO ()) -> Assistant ()
+startSending pip stage sender = do
+       a <- asIO start
+       void $ liftIO $ forkIO a
+  where
+       start = do
+               tid <- liftIO myThreadId
+               let pip' = pip { inProgressPairStage = stage, inProgressThreadId = Just tid }
+               oldpip <- modifyDaemonStatus $
+                       \s -> (s { pairingInProgress = Just pip' }, pairingInProgress s)
+               maybe noop stopold oldpip
+               liftIO $ sender stage
+       stopold = maybe noop (liftIO . killThread) . inProgressThreadId
+
+stopSending :: PairingInProgress -> Assistant ()
+stopSending pip = do
+       maybe noop (liftIO . killThread) $ inProgressThreadId pip
+       modifyDaemonStatus_ $ \s -> s { pairingInProgress = Nothing }
+
+class ToSomeAddr a where
+       toSomeAddr :: a -> SomeAddr
+
+instance ToSomeAddr IPv4 where
+       toSomeAddr (IPv4 a) = IPv4Addr a
+
+instance ToSomeAddr IPv6 where
+       toSomeAddr (IPv6 o1 o2 o3 o4) = IPv6Addr (o1, o2, o3, o4)
+
+showAddr :: SomeAddr -> HostName
+showAddr (IPv4Addr a) = show $ IPv4 a
+showAddr (IPv6Addr (o1, o2, o3, o4)) = show $ IPv6 o1 o2 o3 o4
+
+activeNetworkAddresses :: IO [SomeAddr]
+activeNetworkAddresses = filter (not . all (`elem` "0.:") . showAddr)
+       . concatMap (\ni -> [toSomeAddr $ ipv4 ni, toSomeAddr $ ipv6 ni])
+       <$> getNetworkInterfaces
+
+{- A human-visible description of the repository being paired with.
+ - Note that the repository's description is not shown to the user, because
+ - it could be something like "my repo", which is confusing when pairing
+ - with someone else's repo. However, this has the same format as the
+ - default description of a repo. -}
+pairRepo :: PairMsg -> String
+pairRepo msg = concat
+       [ remoteUserName d
+       , "@"
+       , fromMaybe (showAddr $ pairMsgAddr msg) (remoteHostName d)
+       , ":"
+       , remoteDirectory d
+       ]
+  where
+       d = pairMsgData msg
diff --git a/Assistant/Threads/PairListener.hs b/Assistant/Threads/PairListener.hs
new file mode 100644 (file)
index 0000000..fe39c62
--- /dev/null
@@ -0,0 +1,156 @@
+{- git-annex assistant thread to listen for incoming pairing traffic
+ -
+ - Copyright 2012 Joey Hess <id@joeyh.name>
+ -
+ - Licensed under the GNU AGPL version 3 or higher.
+ -}
+
+{-# LANGUAGE OverloadedStrings #-}
+
+module Assistant.Threads.PairListener where
+
+import Assistant.Common
+import Assistant.Pairing
+import Assistant.Pairing.Network
+import Assistant.Pairing.MakeRemote
+import Assistant.WebApp (UrlRenderer)
+import Assistant.WebApp.Types
+import Assistant.Alert
+import Assistant.DaemonStatus
+import Utility.ThreadScheduler
+import Git
+
+import Network.Multicast
+import Network.Socket
+import qualified Data.ByteString as B
+import qualified Data.ByteString.UTF8 as BU8
+import qualified Network.Socket.ByteString as B
+import qualified Data.Text as T
+
+pairListenerThread :: UrlRenderer -> NamedThread
+pairListenerThread urlrenderer = namedThread "PairListener" $ do
+       listener <- asIO1 $ go [] []
+       liftIO $ withSocketsDo $
+               runEvery (Seconds 60) $ void $ tryIO $ 
+                       listener =<< getsock
+  where
+       {- Note this can crash if there's no network interface,
+        - or only one like lo that doesn't support multicast. -}
+       getsock = multicastReceiver (multicastAddress IPv4AddrClass) pairingPort
+               
+       go reqs cache sock = liftIO (getmsg sock B.empty) >>= \msg -> case readish (BU8.toString msg) of
+               Nothing -> go reqs cache sock
+               Just m -> do
+                       debug ["received", show msg]
+                       (pip, verified) <- verificationCheck m
+                               =<< (pairingInProgress <$> getDaemonStatus)
+                       let wrongstage = maybe False (\p -> pairMsgStage m <= inProgressPairStage p) pip
+                       let fromus = maybe False (\p -> remoteSshPubKey (pairMsgData m) == remoteSshPubKey (inProgressPairData p)) pip
+                       case (wrongstage, fromus, checkSane (pairMsgData m), pairMsgStage m) of
+                               (_, True, _, _) -> do
+                                       debug ["ignoring message that looped back"]
+                                       go reqs cache sock
+                               (_, _, False, _) -> do
+                                       liftAnnex $ warning $ UnquotedString $
+                                               "illegal control characters in pairing message; ignoring (" ++ show (pairMsgData m) ++ ")"
+                                       go reqs cache sock
+                               -- PairReq starts a pairing process, so a
+                               -- new one is always heeded, even if
+                               -- some other pairing is in process.
+                               (_, _, _, PairReq) -> if m `elem` reqs
+                                       then go reqs (invalidateCache m cache) sock
+                                       else do
+                                               pairReqReceived verified urlrenderer m
+                                               go (m:take 10 reqs) (invalidateCache m cache) sock
+                               (True, _, _, _) -> do
+                                       debug
+                                               ["ignoring out of order message"
+                                               , show (pairMsgStage m)
+                                               , "expected"
+                                               , show (succ . inProgressPairStage <$> pip)
+                                               ]
+                                       go reqs cache sock
+                               (_, _, _, PairAck) -> do
+                                       cache' <- pairAckReceived verified pip m cache
+                                       go reqs cache' sock
+                               (_,_ , _, PairDone) -> do
+                                       pairDoneReceived verified pip m
+                                       go reqs cache sock
+
+       {- As well as verifying the message using the shared secret,
+        - check its UUID against the UUID we have stored. If
+        - they're the same, someone is sending bogus messages,
+        - which could be an attempt to brute force the shared secret. -}
+       verificationCheck _ Nothing = return (Nothing, False)
+       verificationCheck m (Just pip)
+               | not verified && sameuuid = do
+                       liftAnnex $ warning
+                               "detected possible pairing brute force attempt; disabled pairing"
+                       stopSending pip
+                       return (Nothing, False)
+               | otherwise = return (Just pip, verified && sameuuid)
+         where
+               verified = verifiedPairMsg m pip
+               sameuuid = pairUUID (inProgressPairData pip) == pairUUID (pairMsgData m)
+
+       {- PairReqs invalidate the cache of recently finished pairings.
+        - This is so that, if a new pairing is started with the
+        - same secret used before, a bogus PairDone is not sent. -}
+       invalidateCache msg = filter (not . verifiedPairMsg msg)
+
+       getmsg sock c = do
+               (msg, _) <- B.recvFrom sock chunksz
+               if B.length msg < chunksz
+                       then return $ c <> msg
+                       else getmsg sock $ c <> msg
+         where
+               chunksz = 1024
+
+{- Show an alert when a PairReq is seen. -}
+pairReqReceived :: Bool -> UrlRenderer -> PairMsg -> Assistant ()
+pairReqReceived True _ _ = noop -- ignore our own PairReq
+pairReqReceived False urlrenderer msg = do
+       button <- mkAlertButton True (T.pack "Respond") urlrenderer (FinishLocalPairR msg)
+       void $ addAlert $ pairRequestReceivedAlert repo button
+  where
+       repo = pairRepo msg
+
+{- When a verified PairAck is seen, a host is ready to pair with us, and has
+ - already configured our ssh key. Stop sending PairReqs, finish the pairing,
+ - and send a single PairDone. -}
+pairAckReceived :: Bool -> Maybe PairingInProgress -> PairMsg -> [PairingInProgress] -> Assistant [PairingInProgress]
+pairAckReceived True (Just pip) msg cache = do
+       stopSending pip
+       repodir <- repoPath <$> liftAnnex gitRepo
+       liftIO $ setupAuthorizedKeys msg repodir
+       finishedLocalPairing msg (inProgressSshKeyPair pip)
+       startSending pip PairDone $ multicastPairMsg
+               (Just 1) (inProgressSecret pip) (inProgressPairData pip)
+       return $ pip : take 10 cache
+{- A stale PairAck might also be seen, after we've finished pairing.
+ - Perhaps our PairDone was not received. To handle this, we keep
+ - a cache of recently finished pairings, and re-send PairDone in
+ - response to stale PairAcks for them. -}
+pairAckReceived _ _ msg cache = do
+       let pips = filter (verifiedPairMsg msg) cache
+       unless (null pips) $
+               forM_ pips $ \pip ->
+                       startSending pip PairDone $ multicastPairMsg
+                               (Just 1) (inProgressSecret pip) (inProgressPairData pip)
+       return cache
+
+{- If we get a verified PairDone, the host has accepted our PairAck, and
+ - has paired with us. Stop sending PairAcks, and finish pairing with them.
+ -
+ - TODO: Should third-party hosts remove their pair request alert when they
+ - see a PairDone?
+ - Complication: The user could have already clicked on the alert and be
+ - entering the secret. Would be better to start a fresh pair request in this
+ - situation.
+ -}
+pairDoneReceived :: Bool -> Maybe PairingInProgress -> PairMsg -> Assistant ()
+pairDoneReceived False _ _ = noop -- not verified
+pairDoneReceived True Nothing _ = noop -- not in progress
+pairDoneReceived True (Just pip) msg = do
+       stopSending pip
+       finishedLocalPairing msg (inProgressSshKeyPair pip)
index ea9ddd6788a1f2e5a8bf7286030d0babae9b5923..a9ed6c0be104b5602dfe986e230e7935068e4498 100644 (file)
@@ -13,6 +13,13 @@ module Assistant.WebApp.Configurators.Pairing where
 import Assistant.Pairing
 import Assistant.WebApp.Common
 import Annex.UUID
+#ifdef WITH_PAIRING
+import Assistant.DaemonStatus
+import Assistant.Pairing.MakeRemote
+import Assistant.Pairing.Network
+import Assistant.Ssh
+import Utility.Verifiable
+#endif
 import Utility.UserInfo
 import Utility.Tor
 import Utility.Su
@@ -31,6 +38,13 @@ import Utility.Process.Transcript
 
 import qualified Data.Map as M
 import qualified Data.Text as T
+#ifdef WITH_PAIRING
+import qualified Data.Text.Encoding as T
+import qualified Data.ByteString as B
+import Data.Char
+import qualified Control.Exception as E
+import Control.Concurrent
+#endif
 import Control.Concurrent.STM hiding (check)
 
 getStartWormholePairFriendR :: Handler Html
@@ -137,5 +151,171 @@ whenWormholeInstalled a = ifM (liftIO Wormhole.isInstalled)
                $(widgetFile "configurators/needmagicwormhole")
        )
 
+{- Starts local pairing. -}
+getStartLocalPairR :: Handler Html
+getStartLocalPairR = postStartLocalPairR
+postStartLocalPairR :: Handler Html
+#ifdef WITH_PAIRING
+postStartLocalPairR = promptSecret Nothing $
+       startLocalPairing PairReq noop pairingAlert Nothing
+#else
+postStartLocalPairR = noLocalPairing
+
+noLocalPairing :: Handler Html
+noLocalPairing = noPairing "local"
+#endif
+
+{- Runs on the system that responds to a local pair request; sets up the ssh
+ - authorized key first so that the originating host can immediately sync
+ - with us. -}
+getFinishLocalPairR :: PairMsg -> Handler Html
+getFinishLocalPairR = postFinishLocalPairR
+postFinishLocalPairR :: PairMsg -> Handler Html
+#ifdef WITH_PAIRING
+postFinishLocalPairR msg = promptSecret (Just msg) $ \_ secret -> do
+       repodir <- liftH $ repoPath <$> liftAnnex gitRepo
+       liftIO $ setup repodir
+       startLocalPairing PairAck (cleanup repodir) alert uuid "" secret
+  where
+       alert = pairRequestAcknowledgedAlert (pairRepo msg) . Just
+       setup repodir = setupAuthorizedKeys msg repodir
+       cleanup repodir = removeAuthorizedKeys True repodir $
+               remoteSshPubKey $ pairMsgData msg
+       uuid = Just $ pairUUID $ pairMsgData msg
+#else
+postFinishLocalPairR _ = noLocalPairing
+#endif
+
+getRunningLocalPairR :: SecretReminder -> Handler Html
+#ifdef WITH_PAIRING
+getRunningLocalPairR s = pairPage $ do
+       let secret = fromSecretReminder s
+       $(widgetFile "configurators/pairing/local/inprogress")
+#else
+getRunningLocalPairR _ = noLocalPairing
+#endif
+
+#ifdef WITH_PAIRING
+
+{- Starts local pairing, at either the PairReq (initiating host) or 
+ - PairAck (responding host) stage.
+ -
+ - Displays an alert, and starts a thread sending the pairing message,
+ - which will continue running until the other host responds, or until
+ - canceled by the user. If canceled by the user, runs the oncancel action.
+ -
+ - Redirects to the pairing in progress page.
+ -}
+startLocalPairing :: PairStage -> IO () -> (AlertButton -> Alert) -> Maybe UUID -> Text -> Secret -> Widget
+startLocalPairing stage oncancel alert muuid displaysecret secret = do
+       urlrender <- liftH getUrlRender
+       reldir <- fromJust . relDir <$> liftH getYesod
+
+       sendrequests <- liftAssistant $ asIO2 $ mksendrequests urlrender
+       {- Generating a ssh key pair can take a while, so do it in the
+        - background. -}
+       thread <- liftAssistant $ asIO $ do
+               keypair <- liftIO $ genSshKeyPair
+               let pubkey = either giveup id $ validateSshPubKey $ sshPubKey keypair
+               pairdata <- liftIO $ PairData
+                       <$> getHostname
+                       <*> (either giveup id <$> myUserName)
+                       <*> pure reldir
+                       <*> pure pubkey
+                       <*> (maybe genUUID return muuid)
+               let sender = multicastPairMsg Nothing secret pairdata
+               let pip = PairingInProgress secret Nothing keypair pairdata stage
+               startSending pip stage $ sendrequests sender
+       void $ liftIO $ forkIO thread
+
+       liftH $ redirect $ RunningLocalPairR $ toSecretReminder displaysecret
+  where
+       {- Sends pairing messages until the thread is killed,
+        - and shows an activity alert while doing it.
+        -
+        - The cancel button returns the user to the DashboardR. This is
+        - not ideal, but they have to be sent somewhere, and could
+        - have been on a page specific to the in-process pairing
+        - that just stopped, so can't go back there.
+        -}
+       mksendrequests urlrender sender _stage = do
+               tid <- liftIO myThreadId
+               let selfdestruct = AlertButton
+                       { buttonLabel = "Cancel"
+                       , buttonPrimary = True
+                       , buttonUrl = urlrender DashboardR
+                       , buttonAction = Just $ const $ do
+                               oncancel
+                               killThread tid
+                       }
+               alertDuring (alert selfdestruct) $ liftIO $ do
+                       _ <- E.try (sender stage) :: IO (Either E.SomeException ())
+                       return ()
+
+data InputSecret = InputSecret { secretText :: Maybe Text }
+
+{- If a PairMsg is passed in, ensures that the user enters a secret
+ - that can validate it. -}
+promptSecret :: Maybe PairMsg -> (Text -> Secret -> Widget) -> Handler Html
+promptSecret msg cont = pairPage $ do
+       ((result, form), enctype) <- liftH $
+               runFormPostNoToken $ renderBootstrap3 bootstrapFormLayout $
+                       InputSecret <$> aopt textField (bfs "Secret phrase") Nothing
+       case result of
+               FormSuccess v -> do
+                       let rawsecret = fromMaybe "" $ secretText v
+                       let secret = toSecret rawsecret
+                       case msg of
+                               Nothing -> case secretProblem secret of
+                                       Nothing -> cont rawsecret secret
+                                       Just problem ->
+                                               showform form enctype $ Just problem
+                               Just m ->
+                                       if verify (fromPairMsg m) secret
+                                               then cont rawsecret secret
+                                               else showform form enctype $ Just
+                                                       "That's not the right secret phrase."
+               _ -> showform form enctype Nothing
+  where
+       showform form enctype mproblem = do
+               let start = isNothing msg
+               let badphrase = isJust mproblem
+               let problem = fromMaybe "" mproblem
+               let (username, hostname) = maybe ("", "")
+                       (\(_, v, a) -> (T.pack $ remoteUserName v, T.pack $ fromMaybe (showAddr a) (remoteHostName v)))
+                       (verifiableVal . fromPairMsg <$> msg)
+               u <- liftIO myUserName
+               let sameusername = Right username == (T.pack <$> u)
+               $(widgetFile "configurators/pairing/local/prompt")
+
+{- This counts unicode characters as more than one character,
+ - but that's ok; they *do* provide additional entropy. -}
+secretProblem :: Secret -> Maybe Text
+secretProblem s
+       | B.null s = Just "The secret phrase cannot be left empty. (Remember that punctuation and white space is ignored.)"
+       | B.length s < 6 = Just "Enter a longer secret phrase, at least 6 characters, but really, a phrase is best! This is not a password you'll need to enter every day."
+       | s == toSecret sampleQuote = Just "Speaking of foolishness, don't paste in the example I gave. Enter a different phrase, please!"
+       | otherwise = Nothing
+
+toSecret :: Text -> Secret
+toSecret s = T.encodeUtf8 $ T.toLower $ T.filter isAlphaNum s
+
+{- From Dickens -}
+sampleQuote :: Text
+sampleQuote = T.unwords
+       [ "It was the best of times,"
+       , "it was the worst of times,"
+       , "it was the age of wisdom,"
+       , "it was the age of foolishness."
+       ]
+
+#else
+
+#endif
+
 pairPage :: Widget -> Handler Html
 pairPage = page "Pairing" (Just Configuration)
+
+noPairing :: Text -> Handler Html
+noPairing pairingtype = pairPage $
+       $(widgetFile "configurators/pairing/disabled")
index 8a75d22be1b1ec6529fc56d4e8c0dfdb193a82fa..526a8741bcc1a96adea858d47262f1dec4c34e91 100644 (file)
 /config/repository/add/cloud/IA AddIAR GET POST
 /config/repository/add/cloud/glacier AddGlacierR GET POST
 
+/config/repository/pair/local/start StartLocalPairR GET POST
+/config/repository/pair/local/running/#SecretReminder RunningLocalPairR GET
+/config/repository/pair/local/finish/#PairMsg FinishLocalPairR GET POST
+
 /config/repository/pair/wormhole/start/self StartWormholePairSelfR GET
 /config/repository/pair/wormhole/start/friend StartWormholePairFriendR GET
 /config/repository/pair/wormhole/prepare/#PairingWith PrepareWormholePairR GET
index 680364b920bc9c4449414d71b681ed5c09747310..53ea8e6fe3d162a5fddaa2f40aee98c5af886cc2 100644 (file)
@@ -26,6 +26,11 @@ buildFlags = filter (not . null)
 #else
 #warning Building without the webapp.
 #endif
+#ifdef WITH_PAIRING
+       , "Pairing"
+#else
+#warning Building without local pairing.
+#endif
 #ifdef WITH_INOTIFY
        , "Inotify"
 #endif
index 22dda9dbc03be16ae44a3243107e326fa173d278..c9eabe919c6147f0e33a9feebe3c6cf615cef850 100644 (file)
--- a/CHANGELOG
+++ b/CHANGELOG
@@ -1,11 +1,3 @@
-git-annex (10.20250930) UNRELEASED; urgency=medium
-
-  * webapp: Remove support for local pairing; use wormhole pairing instead.
-  * git-annex.cabal: Removed pairing build flag, and no longer depends
-    on network-multicast or network-info.
-
- -- Joey Hess <id@joeyh.name>  Mon, 29 Sep 2025 12:35:51 -0400
-
 git-annex (10.20250929) upstream; urgency=medium
 
   * enableremote: Allow type= to be provided when it does not change the
index 7484f046584f1489542f9c6ba2b5decad32e205f..a6911a972468296d1ee2ba6a4bfb87b63e89fb9b 100644 (file)
@@ -58,6 +58,8 @@ Build-Depends:
        libghc-http-client-restricted-dev,
        libghc-blaze-builder-dev,
        libghc-crypto-api-dev,
+       libghc-network-multicast-dev,
+       libghc-network-info-dev [linux-any kfreebsd-any],
        libghc-safesemaphore-dev,
        libghc-async-dev,
        libghc-monad-logger-dev,
index 6ed991c6bbd065fcb01056550a8d4dab86b9c246..0147f6ddf4d024f7232af337d6685dfc8d0c6128 100644 (file)
@@ -8,6 +8,9 @@ It's very easy to use, and has all the power of git and git-annex.
 The git-annex assistant comes as part of git-annex. 
 See [[install]] to get it installed.
 
+See the [[release_notes]] for an overview of the status, and upgrade
+instructions.
+
 ## intro screencast
 
 [[!inline feeds=no template=bare pages=videos/git-annex_assistant_lan]]
@@ -16,6 +19,8 @@ See [[install]] to get it installed.
 
 * [[Basic usage|quickstart]]
 * [[Android documentation|/Android]]
+* Want to make two nearby computers share the same synchronised folder?  
+  Follow the [[local_pairing_walkthrough]].
 * Want to share files with a friend? Follow the
   [[share_with_a_friend_walkthrough]].
 * Want to archive data to a drive or the cloud?  
diff --git a/doc/assistant/local_pairing_walkthrough.mdwn b/doc/assistant/local_pairing_walkthrough.mdwn
new file mode 100644 (file)
index 0000000..c0a760e
--- /dev/null
@@ -0,0 +1,90 @@
+So you have two computers in the same building, and you want them to share
+the same synchronised folder, communicating directly with each other.
+
+This is incredibly easy to set up with the git annex assistant.
+
+Let's say the two computers are your computer and your friend's computer.
+We'll start on your computer, where you open up your git annex dashboard.
+
+[[!img addrepository.png alt="Add another repository button"]]
+
+`*click*`
+
+[[!img pairing.png alt="Pair with another computer"]]
+
+`*click*`
+
+Now the hard bit. You have to think up a secret phrase, and type it in,
+(and perhaps get the spelling correct).
+
+[[!img secret.png alt="Enter secret phrase"]]
+
+Now your computer is in pairing mode. When your friend looks at her git
+annex dashboard, she sees something like this.
+
+[[!img pairrequest.png alt="Pair request"]]
+
+`*click*`
+
+[[!img secretempty.png alt="Enter same secret phrase"]]
+
+Now it's up to you to let her know what the secret is. As soon as she
+enters it, both your computers will be paired, and will begin to sync their
+git-annex folders. Just like that you can share files.
+
+----
+
+## Requirements
+
+For local pairing to work, you must have sshd (ssh server daemon) installed and working on all machines involved. That means you must allow at least local connections to sshd. On most Linux distributions, sshd is packaged in either openssh (openSUSE) or openssh-server (Debian). 
+
+It is highly recommended that you disable root login, disable password login to sshd and just enable key based authentication instead. No one will be able to login without your key.
+
+To disable root, after installing sshd, edit the sshd config (usually /etc/ssh/sshd_config file) and disable root login by adding:
+
+    PermitRootLogin no
+
+Restart sshd. See man sshd_config for details.
+
+To disable password login and enable key based authentication, edit the sshd config (just like above) by uncommenting and changing the following options:
+
+    ChallengeResponseAuthentication no
+    PasswordAuthentication no
+    UsePAM no
+    
+    PubkeyAuthentication yes
+
+Restart sshd. See man sshd_config for details.
+
+You can also restrict login to your local network only (not allow internet users from trying to log into your computer). Edit the hosts.deny file (usually /etc/hosts.deny) by adding the following:
+
+    sshd : ALL EXCEPT LOCAL
+
+Do note that restricting login to your local network may or may not block git-annex. Also note that this will not work on Mac OSX because Apple decided to disable this feature and replace it with a crippled version made by Apple.
+
+## Tips
+
+Something to keep in mind, especially if pairing doesn't seem to be
+working, is that the two computers need to be on the same network for this
+pairing process to work. Sometimes a building will have more than one
+network inside it, and you'll need to connect them both to the same one.
+Make sure the wireless network name is the same, or that they're both
+plugged into the same router.
+
+Also, the file sharing set up by this pairing only works when both
+computers are on the same network. If you go on a trip, any files you
+edit will not be visible to your friend until you get back. 
+
+To get around this, you'll often also want to set up
+[[tor_pairing|share_with_a_friend_walkthrough]] too, 
+which they can use to exchange files while away.
+
+And also, you can pair with as many other computers as you like, not just
+one!
+
+## What does pairing actually do behind the scenes?
+
+It ensures that both repositories have correctly configured 
+[[remotes|walkthrough/adding_a_remote]] pointing to each other.
+If you have already configured this manually, you do not need to
+perform pairing.
index b2fe895a0d9b037f29a7be661c9f9ff009714008..ec8f39d531b97c23420c699978dbdd8882837f35 100644 (file)
@@ -4,3 +4,9 @@ to share the same synchronised folder, communicating directly with each other.
 [[!inline feeds=no template=bare pages=videos/git-annex_assistant_remote_sharing]]
 
 You can add even more computers using the same method shown here.
+
+----
+
+If you have a laptop that is sometimes near another computer, you can
+speed up file transfers when it is by also connecting it using the
+[[local_pairing_walkthrough]].
index bde8bf4e57ac1826d5e9b0a8d9da1c2dc0c407cd..bff665a5e97dd5376d7cd9fa44f2372be497512f 100644 (file)
@@ -175,5 +175,3 @@ fatal: The remote end hung up unexpectedly
 """]]
 
 [[!taglink moreinfo]]
-
-> This feature has been removed. [[done]] --[[Joey]]
index ba66115ab9b73ea1d1826779d0990bd295dedf2d..14223a1706d55886fdc62341a5f84e6192674def 100644 (file)
@@ -27,7 +27,6 @@ have. Beyond the business of connecting to the webapp securely, the adhoc
 network protocol used by the webapp's pairing interface is baked into the
 assistant even when the webapp is not being used. And is not otherwise used
 in git-annex, and has had at least one security issue in the past.
-(Update: That has been removed from the webapp now.)
 
 The git-annex binary also ends up significantly larger due to containing
 the webapp. And removing it deletes 28 thousand lines of code from
index 64bc69f2087afe406dcca8fd9eaa6068fa61a894..209a3be79b2309aa9008c7bc737b7e8d0308a6ab 100644 (file)
@@ -96,8 +96,11 @@ Extra-Source-Files:
   templates/configurators/newrepository/first.hamlet
   templates/configurators/newrepository/combine.hamlet
   templates/configurators/enablewebdav.hamlet
+  templates/configurators/pairing/local/inprogress.hamlet
+  templates/configurators/pairing/local/prompt.hamlet
   templates/configurators/pairing/wormhole/prompt.hamlet
   templates/configurators/pairing/wormhole/start.hamlet
+  templates/configurators/pairing/disabled.hamlet
   templates/configurators/addglacier.hamlet
   templates/configurators/fsck.cassius
   templates/configurators/edit/nonannexremote.hamlet
@@ -149,6 +152,9 @@ Flag Assistant
   Description: Enable git-annex assistant, webapp, and watch command
   Default: True
 
+Flag Pairing
+  Description: Enable pairing
+
 Flag Production
   Description: Enable production build (slower build; faster binary)
 
@@ -361,6 +367,8 @@ Executable git-annex
       Assistant.Monad
       Assistant.NamedThread
       Assistant.Pairing
+      Assistant.Pairing.MakeRemote
+      Assistant.Pairing.Network
       Assistant.Pushes
       Assistant.RemoteControl
       Assistant.Repair
@@ -378,6 +386,7 @@ Executable git-annex
       Assistant.Threads.Merger
       Assistant.Threads.MountWatcher
       Assistant.Threads.NetWatcher
+      Assistant.Threads.PairListener
       Assistant.Threads.ProblemFixer
       Assistant.Threads.Pusher
       Assistant.Threads.RemoteControl
@@ -475,6 +484,10 @@ Executable git-annex
       CPP-Options: -DWITH_DBUS -DWITH_DESKTOP_NOTIFY -DWITH_DBUS_NOTIFICATIONS
       Other-Modules: Utility.DBus
 
+  if flag(Pairing)
+    Build-Depends: network-multicast, network-info
+    CPP-Options: -DWITH_PAIRING
+
   if flag(TorrentParser)
     Build-Depends: torrent (>= 10000.0.0)
     CPP-Options: -DWITH_TORRENTPARSER
index 25e54ff7580513748e9eec213db6a80b62e2d202..f4c26e49aeac30019069612e989e3d7cb59a77a9 100644 (file)
@@ -3,6 +3,7 @@ flags:
     production: true
     parallelbuild: true
     assistant: true
+    pairing: true
     torrentparser: true
     magicmime: false
     dbus: false
index 5a030893bd94fd2405e75b22f24a0ee8c66c0191..4a4c2aaea2460b5f85d4562357dd82537d720a13 100644 (file)
 
 ^{makeWormholePairing}
 
+<h3>
+  <a href="@{StartLocalPairR}">
+    <span .glyphicon .glyphicon-plus-sign>
+    \ Local computer
+<p>
+  Pair with a computer to keep files in sync quickly, #
+  over your local network.
+
 <h3>
   <a href="@{NewRepositoryR}">
     <span .glyphicon .glyphicon-plus-sign>
diff --git a/templates/configurators/pairing/disabled.hamlet b/templates/configurators/pairing/disabled.hamlet
new file mode 100644 (file)
index 0000000..05f5914
--- /dev/null
@@ -0,0 +1,6 @@
+<div .col-sm-9>
+  <div .content-box>
+    <h2>
+      not supported
+    <p>
+      This build of git-annex does not support #{pairingtype} pairing. Sorry!
diff --git a/templates/configurators/pairing/local/inprogress.hamlet b/templates/configurators/pairing/local/inprogress.hamlet
new file mode 100644 (file)
index 0000000..38e353a
--- /dev/null
@@ -0,0 +1,19 @@
+<div .col-sm-9>
+  <div .content-box>
+    <h2>
+      Pairing in progress ..
+    $if T.null secret
+      <p>
+        You do not need to leave this page open; pairing will finish #
+        automatically.
+    $else
+      <p>
+        Now you should either go tell the owner of the computer you want to pair #
+        with the secret phrase you selected ("#{secret}"), or go enter it into #
+        the computer you want to pair with.
+      <p>
+        You do not need to leave this page open; pairing will finish automatically #
+        as soon as the secret phrase is entered into the other computer.
+      <p>
+        If you're not seeing a pair request on the other computer, try moving #
+        it to the same switch or wireless network as this one.
diff --git a/templates/configurators/pairing/local/prompt.hamlet b/templates/configurators/pairing/local/prompt.hamlet
new file mode 100644 (file)
index 0000000..b75e2e2
--- /dev/null
@@ -0,0 +1,53 @@
+<div .col-sm-9>
+  <div .content-box>
+    <h2>
+      Pairing with a local computer
+    <p>
+      $if start
+        Pair with a computer on your local network (or VPN), and the #
+        two git annex repositories will be combined into one, with changes #
+        kept in sync between them.
+      $else
+        Pairing with #{username}@#{hostname} will combine your two git annex #
+        repositories into one, allowing you to share files.
+    <p>
+      $if start
+        For security, enter a secret phrase. To verify that you're pairing #
+        with the right computer, its git-annex webapp will then prompt for the #
+        same secret phrase. That's all this secret phrase will be used for.
+      $else
+        $if sameusername
+          For security, you need to enter the same secret phrase that was #
+          entered on #{hostname} when the pairing was started.
+        $else
+          For security, a secret phrase has been selected, which you need #
+          to enter here to finish the pairing. If you don't know the #
+          phrase, go ask #{username} ...
+    $if badphrase
+      <div .alert .alert-danger>
+        <span .glyphicon .glyphicon-warning-sign>
+        \ #{problem}
+    <p>
+      <form method="post" .form-horizontal enctype=#{enctype}>
+        <fieldset>
+          ^{form}
+          ^{webAppFormAuthToken}
+          <div .form-group>
+            <div .col-sm-10 .col-sm-offset-2>
+              <button .btn .btn-primary type=submit>
+                $if start
+                  Start pairing
+                $else
+                  Finish pairing
+      <div .alert .alert-info>
+        $if start
+          <p>
+            A good secret phrase is reasonably long. You'll only #
+            type it a few times. Only letters and numbers matter; #
+            punctuation and white space is ignored.
+          <p>
+            A quotation is one good choice, something like: #
+            "#{sampleQuote}"
+        $else
+          Only letters and numbers matter; punctuation and spaces are #
+          ignored.